// Copyright 2019-2024 Xu Ruibo (hustxurb@163.com) and Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
	"encoding/json"
	"fmt"
	"net"
	"net/http"
	"net/http/httputil"
	"net/url"
	"os"
	"path/filepath"
	"runtime"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	dbclient "github.com/zuoyebang/bitalostored/dashboard/models/db"

	"gorm.io/driver/sqlite"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"

	"github.com/docopt/docopt-go"
	"github.com/go-martini/martini"
	"github.com/martini-contrib/render"

	"github.com/zuoyebang/bitalostored/dashboard/internal/errors"
	"github.com/zuoyebang/bitalostored/dashboard/internal/log"
	"github.com/zuoyebang/bitalostored/dashboard/internal/rpc"
	"github.com/zuoyebang/bitalostored/dashboard/internal/sync2/atomic2"
	"github.com/zuoyebang/bitalostored/dashboard/internal/utils"
	"github.com/zuoyebang/bitalostored/dashboard/models"
)

var roundTripper http.RoundTripper

func init() {
	var dials atomic2.Int64
	tr := &http.Transport{}
	tr.Dial = func(network, addr string) (net.Conn, error) {
		c, err := net.DialTimeout(network, addr, time.Second*10)
		if err == nil {
			log.Debugf("rpc: dial new connection to [%d] %s - %s",
				dials.Incr()-1, network, addr)
		}
		return c, err
	}
	go func() {
		for {
			time.Sleep(time.Minute)
			tr.CloseIdleConnections()
		}
	}()
	roundTripper = tr
}

func main() {
	const usage = `
Usage:
	bitalos-fe [--ncpu=N] [--log=FILE] [--log-level=LEVEL] [--assets-dir=PATH] [--pidfile=FILE] (--db=ADDR [--db-username=USR] [--db-password=PSW] [--db-hostport=HPT] [--db-dbname=DBN] |--sqlite=FILE) --listen=ADDR
	bitalos-fe  --version

Options:
	--ncpu=N                        set runtime.GOMAXPROCS to N, default is runtime.NumCPU().
	-d FILE, --dashboard-list=FILE  set list of dashboard, can be generated by sproxy-admin.
	-l FILE, --log=FILE             set path/name of daliy rotated log file.
	--log-level=LEVEL               set the log-level, should be INFO,WARN,DEBUG or ERROR, default is INFO.
	--listen=ADDR                   set the listen address.
`
	d, err := docopt.ParseArgs(usage, nil, "")
	if err != nil {
		log.PanicError(err, "parse arguments failed")
	}

	if d["--version"].(bool) {
		fmt.Println("version:", utils.Version)
		fmt.Println("compile:", utils.Compile)
		return
	}

	if s, ok := utils.Argument(d, "--log"); ok {
		w, err := log.NewRollingFile(s, log.HourlyRolling)
		if err != nil {
			log.PanicErrorf(err, "open log file %s failed", s)
		} else {
			log.StdLog = log.New(w, "")
		}
	}
	log.SetLevel(log.LevelInfo)

	if s, ok := utils.Argument(d, "--log-level"); ok {
		if !log.SetLevelString(s) {
			log.Panicf("option --log-level = %s", s)
		}
	}

	if n, ok := utils.ArgumentInteger(d, "--ncpu"); ok {
		runtime.GOMAXPROCS(n)
	} else {
		runtime.GOMAXPROCS(runtime.NumCPU())
	}
	log.Warnf("set ncpu = %d", runtime.GOMAXPROCS(0))

	listen := utils.ArgumentMust(d, "--listen")
	log.Warnf("set listen = %s", listen)

	var assets string
	if s, ok := utils.Argument(d, "--assets-dir"); ok {
		abspath, err := filepath.Abs(s)
		if err != nil {
			log.PanicErrorf(err, "get absolute path of %s failed", s)
		}
		assets = abspath
	} else {
		binpath, err := filepath.Abs(filepath.Dir(os.Args[0]))
		if err != nil {
			log.PanicErrorf(err, "get path of binary failed")
		}
		assets = filepath.Join(binpath, "assets")
	}
	log.Warnf("set assets = %s", assets)

	indexFile := filepath.Join(assets, "index.html")
	if _, err := os.Stat(indexFile); err != nil {
		log.PanicErrorf(err, "get stat of %s failed", indexFile)
	}

	var loader ConfigLoader
	var coordinator struct {
		name string
		addr string
		auth string
	}

	var db *gorm.DB

	switch {
	case d["--db"] != nil:
		coordinator.name = "database"
		coordinator.addr = utils.ArgumentMust(d, "--database")
		dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
			utils.ArgumentMust(d, "--db-username"), utils.ArgumentMust(d, "--db-password"), utils.ArgumentMust(d, "--db-hostport"), utils.ArgumentMust(d, "--db-dbname"))
		db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
		if err != nil {
			log.PanicErrorf(err, "connect db failed.")
		}
	case d["--sqlite"] != nil:
		coordinator.name = "sqlite"
		coordinator.addr = utils.ArgumentMust(d, "--sqlite")
		db, err = gorm.Open(sqlite.Open(coordinator.addr), &gorm.Config{})
		if err != nil {
			log.PanicErrorf(err, "connect sqlite failed.%+v", err)
		}
		log.Warnf("option --sqlite = %s", coordinator.addr)
	default:
		log.Panicf("invalid coordinator")
	}
	log.Warnf("set --%s = %s", coordinator.name, coordinator.addr)

	c, err := models.NewClient(coordinator.name, db)
	if err != nil {
		log.PanicErrorf(err, "create '%s' client to '%s' failed", coordinator.name, coordinator.addr)
	}
	defer c.Close()

	loader = &DynamicLoader{c}
	if coordinator.name == "database" {
		directOperator.Client = c
		directOperator.mu = &sync.Mutex{}
	} else {
		directOperator.Client = nil
		directOperator.mu = nil
	}

	router := NewReverseProxy(loader)

	m := martini.New()
	m.Use(martini.Recovery())
	m.Use(render.Renderer())
	m.Use(martini.Static(assets, martini.StaticOptions{SkipLogging: true}))

	defer func() {
		if e := recover(); e != nil {
			buf := make([]byte, 2048)
			n := runtime.Stack(buf, false)
			buf = buf[0:n]
			log.Errorf("fe run [err:%v] [panic:%s]", e, string(buf))
		}
	}()

	r := martini.NewRouter()
	r.Get("/list", func() (int, string) {
		names := router.GetNames()
		sort.Sort(sort.StringSlice(names))
		return rpc.ApiResponseJson(names)
	})

	r.Get("/clusters", func() (int, string) {
		names := router.GetClusters()
		return rpc.ApiResponseJson(names)
	})

	r.Get("/constants", func() (int, string) {
		constant := map[string]interface{}{
			"clouds": []string{"tencent", "txcloud", "ali", "baidu"},
		}
		return rpc.ApiResponseJson(constant)
	})

	r.Get("/info", func() (int, string) {
		d := router.GetDetails()
		return rpc.ApiResponseJson(d)
	})

	r.Any("/**", func(w http.ResponseWriter, req *http.Request) {
		path := req.URL.Path
		name := req.URL.Query().Get("forward")
		if len(name) == 0 {
			if strings.Contains(path, "/admin") || strings.HasPrefix(path, "/login") || strings.HasPrefix(path, "/logout") {
				names := router.GetNames()
				sort.Sort(sort.StringSlice(names))
				if len(names) > 0 {
					name = names[0]
				}
			}
		}
		if p := router.GetProxy(name); p != nil {
			p.ServeHTTP(w, req)
		} else {
			w.WriteHeader(http.StatusForbidden)
		}
	})

	m.MapTo(r, (*martini.Routes)(nil))
	m.Action(r.Handle)

	l, err := net.Listen("tcp", listen)
	if err != nil {
		log.PanicErrorf(err, "listen %s failed", listen)
	}
	defer l.Close()

	if s, ok := utils.Argument(d, "--pidfile"); ok {
		if pidfile, err := filepath.Abs(s); err != nil {
			log.WarnErrorf(err, "parse pidfile = '%s' failed", s)
		} else if err := os.WriteFile(pidfile, []byte(strconv.Itoa(os.Getpid())), 0644); err != nil {
			log.WarnErrorf(err, "write pidfile = '%s' failed", pidfile)
		} else {
			defer func() {
				if err := os.Remove(pidfile); err != nil {
					log.WarnErrorf(err, "remove pidfile = '%s' failed", pidfile)
				}
			}()
			log.Warnf("option --pidfile = %s", pidfile)
		}
	}

	h := http.NewServeMux()
	h.Handle("/", m)
	hs := &http.Server{Handler: h}
	if err := hs.Serve(l); err != nil {
		log.PanicErrorf(err, "serve %s failed", listen)
	}
}

var directOperator DBOperator

type DBOperator struct {
	mu     *sync.Mutex
	Client models.Client
}

type ConfigLoader interface {
	Reload() (map[string]string, error, ClusterInfo, map[string]ClusterInfo, map[string]string)
}

type StaticLoader struct {
	path string
}

func (l *StaticLoader) Reload() (map[string]string, error, ClusterInfo, map[string]ClusterInfo, map[string]string) {
	b, err := os.ReadFile(l.path)
	if err != nil {
		return nil, errors.Trace(err), ClusterInfo{}, nil, nil
	}
	var list []*struct {
		Name      string `json:"name"`
		Dashboard string `json:"dashboard"`
	}
	if err := json.Unmarshal(b, &list); err != nil {
		return nil, errors.Trace(err), ClusterInfo{}, nil, nil
	}
	var m = make(map[string]string)
	for _, e := range list {
		m[e.Name] = e.Dashboard
	}
	return m, nil, ClusterInfo{}, nil, nil
}

type DynamicLoader struct {
	client models.Client
}

func (l *DynamicLoader) Reload() (map[string]string, error, ClusterInfo, map[string]ClusterInfo, map[string]string) {
	var m = make(map[string]string)
	var department = make(map[string]string)
	list, err := l.client.List(models.StoredDir)
	if err != nil {
		return nil, errors.Trace(err), ClusterInfo{}, nil, nil
	}

	for _, path := range list {
		product := filepath.Base(path)
		if b, err := l.client.Read(models.LockPath(product)); err != nil {
			log.WarnErrorf(err, "read dashcore of product %s failed", product)
		} else if b != nil {
			var t = &models.DashCore{}
			if err := json.Unmarshal(b, t); err != nil {
				log.WarnErrorf(err, "decode json failed")
			} else {
				m[product] = t.AdminAddr
				name, err := l.client.Read(models.DepartmentPath(product))
				if err != nil {
					log.WarnErrorf(err, "read department error")
					department[product] = ""
				} else {
					if name == nil || len(name) == 0 {
						department[product] = ""
						continue
					}
					var de = &models.Department{}
					if err := json.Unmarshal(name, de); err != nil {
						log.WarnErrorf(err, "decode json failed")
						department[product] = ""
						continue
					}
					department[product] = de.Name
				}
			}
		}
	}

	var details = make(map[string]ClusterInfo)
	var total = ClusterInfo{
		Name:              `total`,
		Groups:            []string{},
		GroupOutOfSync:    []string{},
		GroupDegrade:      []string{},
		GroupUpdateOneDay: []string{},
	}
	for product, _ := range m {
		if b, err := l.client.Details(product); err != nil {
			log.WarnErrorf(err, "read dashcore of product %s failed", product)
		} else if b != nil {
			var cluster = ClusterInfo{
				Name:              product,
				Groups:            []string{},
				GroupOutOfSync:    []string{},
				GroupDegrade:      []string{},
				GroupUpdateOneDay: []string{},
			}
			for i := 0; i < len(b); i++ {
				g := dbclient.Group{}
				if err := json.Unmarshal([]byte(b[i]), &g); err != nil {
					log.WarnErrorf(err, "decode json failed")
				}
				groupName := product + `_group` + strconv.Itoa(g.Id)

				cluster.Groups = append(cluster.Groups, groupName)
				if g.OutOfSync {
					cluster.GroupOutOfSync = append(cluster.GroupOutOfSync, groupName)
				}
				if time.Now().Unix()-g.UpdateTime < 24*3600 {
					cluster.GroupUpdateOneDay = append(cluster.GroupUpdateOneDay, groupName)
				}

			}
			cluster.GroupSum = len(cluster.Groups)
			cluster.GroupOutOfSyncSum = len(cluster.GroupOutOfSync)

			details[product] = cluster

			total.Groups = append(total.Groups, cluster.Groups...)
			total.GroupOutOfSync = append(total.GroupOutOfSync, cluster.GroupOutOfSync...)
			total.GroupSum = total.GroupSum + cluster.GroupSum
			total.GroupOutOfSyncSum = total.GroupOutOfSyncSum + cluster.GroupOutOfSyncSum
			total.GroupUpdateOneDay = append(total.GroupUpdateOneDay, cluster.GroupUpdateOneDay...)
		}
	}
	return m, nil, total, details, department
}

type ReverseProxy struct {
	sync.Mutex
	loadAt  time.Time
	loader  ConfigLoader
	routes  map[string]*httputil.ReverseProxy
	details DetailsInfo

	clusterDepartment map[string]string
}
type DetailsInfo struct {
	Total    []TotalGroupInfo       `json:"total"`
	Clusters map[string]ClusterInfo `json:"clusters"`
}

type TotalGroupInfo struct {
	Name   string   `json:"name"`
	Count  int      `json:"count"`
	Detail []string `json:"detail"`
}

type ClusterInfo struct {
	Name              string   `json:"name"`
	GroupSum          int      `json:"group_sum"`
	Groups            []string `json:"groups"`
	GroupOutOfSyncSum int      `json:"group_out_of_sync_sum"`
	GroupOutOfSync    []string `json:"group_out_of_sync"`    //groupId array
	GroupDegrade      []string `json:"group_degrade"`        //groupId array
	GroupUpdateOneDay []string `json:"group_update_one_day"` //groupId array
}

func NewReverseProxy(loader ConfigLoader) *ReverseProxy {
	r := &ReverseProxy{}
	r.loader = loader
	r.routes = make(map[string]*httputil.ReverseProxy)
	return r
}

func (r *ReverseProxy) reload(d time.Duration) {
	if time.Now().Sub(r.loadAt) < d {
		return
	}
	r.routes = make(map[string]*httputil.ReverseProxy)
	r.clusterDepartment = make(map[string]string, 0)
	if m, err, total, details, department := r.loader.Reload(); err != nil {
		log.WarnErrorf(err, "reload reverse proxy failed")
	} else {
		for name, host := range m {
			if name == "" || host == "" {
				continue
			}
			u := &url.URL{Scheme: "http", Host: host}
			p := httputil.NewSingleHostReverseProxy(u)
			p.Transport = roundTripper
			r.routes[name] = p
		}
		r.details = DetailsInfo{Total: []TotalGroupInfo{
			{`groups`, len(total.Groups), total.Groups},
			{`group_out_of_sync`, len(total.GroupOutOfSync), total.GroupOutOfSync},
			{`group_degrade`, len(total.GroupDegrade), total.GroupDegrade},
			{`group_update_one_day`, len(total.GroupUpdateOneDay), total.GroupUpdateOneDay},
		}, Clusters: details}
		r.clusterDepartment = department
	}
	r.loadAt = time.Now()
}

func (r *ReverseProxy) GetProxy(name string) *httputil.ReverseProxy {
	r.Lock()
	defer r.Unlock()
	return r.routes[name]
}

func (r *ReverseProxy) GetNames() []string {
	r.Lock()
	defer r.Unlock()
	r.reload(time.Second * 5)
	var names []string
	for name, _ := range r.routes {
		names = append(names, name)
	}
	return names
}

func (r *ReverseProxy) GetClusters() []Clusters {
	r.Lock()
	defer r.Unlock()
	r.reload(time.Second * 5)
	departmentClusters := make(map[string][]string, 0)
	for product, department := range r.clusterDepartment {
		if department == "" {
			department = "default"
		}
		departmentClusters[department] = append(departmentClusters[department], product)
	}
	var cluster []Clusters
	for d, cs := range departmentClusters {
		sort.Sort(sort.StringSlice(cs))
		cluster = append(cluster, Clusters{
			DepartmentName: d,
			ClusterList:    cs,
		})
	}
	sort.Sort(ClusterSorter(cluster))

	return cluster
}

type Clusters struct {
	DepartmentName string   `json:"departmentName"`
	ClusterList    []string `json:"clusterList"`
}

type ClusterSorter []Clusters

func (p ClusterSorter) Len() int           { return len(p) }
func (p ClusterSorter) Less(i, j int) bool { return p[i].DepartmentName < p[j].DepartmentName }
func (p ClusterSorter) Swap(i, j int)      { p[i], p[j] = p[j], p[i] }

func (r *ReverseProxy) GetDetails() DetailsInfo {
	r.Lock()
	defer r.Unlock()
	r.reload(time.Second * 5)
	return r.details
}
